# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import json
from pathlib import Path
import sys
import warnings

import numpy as np
from scipy.interpolate import RegularGridInterpolator

from ansys.aedt.core.aedt_logger import pyaedt_logger as logger
from ansys.aedt.core.base import PyAedtBase
from ansys.aedt.core.generic.constants import AEDT_UNITS
from ansys.aedt.core.generic.constants import SpeedOfLight
from ansys.aedt.core.generic.constants import unit_converter
from ansys.aedt.core.generic.general_methods import conversion_function
from ansys.aedt.core.generic.general_methods import pyaedt_function_handler
from ansys.aedt.core.generic.numbers_utils import decompose_variable_value
from ansys.aedt.core.internal.checks import ERROR_GRAPHICS_REQUIRED
from ansys.aedt.core.internal.checks import check_graphics_available
from ansys.aedt.core.internal.checks import graphics_required
from ansys.aedt.core.visualization.plot.matplotlib import ReportPlotter

current_python_version = sys.version_info[:2]
if current_python_version < (3, 10):  # pragma: no cover
    raise Exception("Python 3.10 or higher is required for Monostatic RCS post-processing.")


# Check that graphics are available
try:
    check_graphics_available()

    import pyvista as pv

    from ansys.tools.visualization_interface import MeshObjectPlot
    from ansys.tools.visualization_interface import Plotter
    from ansys.tools.visualization_interface.backends.pyvista import PyVistaBackend
except ImportError:
    warnings.warn(ERROR_GRAPHICS_REQUIRED)


try:
    import pandas as pd
except ImportError:  # pragma: no cover
    warnings.warn(
        "The Pandas module is required to use module rcs_visualization.py.\nInstall with \n\npip install pandas"
    )
    pd = None

try:
    import scipy.interpolate
except ImportError:  # pragma: no cover
    warnings.warn(
        "The SciPy module is required to use module rcs_visualization.py.\nInstall with \n\npip install scipy"
    )


class MonostaticRCSData(PyAedtBase):
    """Provides monostatic radar cross-section (RCS) data.

    Read monostatic RCS metadata in a JSON file generated by :func:`MonostaticRCSExporter` and return the
    Python interface to plot and analyze the RCS data.

    Parameters
    ----------
    input_file : str
        Metadata information in a JSON file.

    Examples
    --------
    >>> from ansys.aedt.core import Hfss
    >>> from ansys.aedt.core.visualization.advanced.rcs_visualization import MonostaticRCSData
    >>> app = Hfss(version="2025.2", design="Antenna")
    >>> data = app.get_rcs_data()
    >>> metadata_file = data.metadata_file
    >>> app.desktop_class.close_desktop()
    >>> rcs_data = MonostaticRCSData(input_file=metadata_file)
    """

    def __init__(self, input_file):
        input_file = Path(input_file)
        # Public
        self.output_dir = input_file.parent

        if not input_file.is_file():
            raise FileNotFoundError("JSON file does not exist.")

        # Private
        self.__logger = logger
        self.__input_file = input_file
        self.__raw_data = {}
        self.__frequency = None
        self.__name = None
        self.__solution = None

        self.__incident_wave_theta = None
        self.__incident_wave_phi = None
        self.__available_incident_wave_theta = None
        self.__available_incident_wave_phi = None

        self.__frequencies = []

        with input_file.open("r") as file:
            self.__metadata = json.load(file)

        self.__frequency_units = self.__metadata["frequency_units"]

        self.__monostatic_file = None
        if self.__metadata["monostatic_file"]:
            self.__monostatic_file = self.output_dir / self.__metadata["monostatic_file"]

        self.__data_conversion_function = "dB20"
        self.__window = "Flat"
        self.__window_size = 1024
        self.__aspect_range = "Horizontal"
        self.__upsample_range = 512
        self.__upsample_azimuth = 64

        if self.__monostatic_file and not self.__monostatic_file.is_file():
            raise Exception("Monostatic file invalid.")

        self.rcs_column_names = ["data"]

        # Load farfield data
        if self.__monostatic_file:
            is_rcs_loaded = self.__init_rcs()
        else:
            is_rcs_loaded = True

        if not is_rcs_loaded:  # pragma: no cover
            raise RuntimeError("RCS information can not be loaded.")

        if self.__monostatic_file:
            # Update active frequency if passed in the initialization
            self.frequency = self.frequencies[0]

    @property
    def raw_data(self):
        """Antenna data."""
        return self.__raw_data

    @property
    def metadata(self):
        """Antenna metadata."""
        return self.__metadata

    @property
    def name(self):
        """Data name."""
        if isinstance(self.raw_data, pd.DataFrame):
            self.__name = self.raw_data.columns[0]
        return self.__name

    @property
    def solution(self):
        """Data solution name."""
        self.__solution = self.__metadata["solution"]
        return self.__solution

    @property
    def input_file(self):
        """Input file."""
        return self.__input_file

    @property
    def frequency_units(self):
        """Frequency units."""
        return self.__frequency_units

    @property
    def frequencies(self):
        """Available frequencies."""
        if isinstance(self.raw_data, pd.DataFrame) and "Freq" in self.raw_data.index.names:
            frequencies = np.unique(np.array(self.raw_data.index.get_level_values("Freq")))
            self.__frequencies = frequencies.tolist()
        return self.__frequencies

    @property
    def available_incident_wave_theta(self):
        """Available incident wave Theta."""
        if isinstance(self.raw_data, pd.DataFrame) and "IWaveTheta" in self.raw_data.index.names:
            self.__available_incident_wave_theta = np.unique(
                np.array(self.raw_data.index.get_level_values("IWaveTheta"))
            )
        return self.__available_incident_wave_theta

    @property
    def incident_wave_theta(self):
        """Active incident wave Theta."""
        if isinstance(self.raw_data, pd.DataFrame) and self.__incident_wave_theta is None:
            self.__incident_wave_theta = self.available_incident_wave_theta[0]
        return self.__incident_wave_theta

    @incident_wave_theta.setter
    def incident_wave_theta(self, val):
        """Active incident wave Theta."""
        if val in self.available_incident_wave_theta:
            self.__incident_wave_theta = val
        else:
            self.__logger.error("Value not available.")

    @property
    def available_incident_wave_phi(self):
        """Available incident wave Phi."""
        if isinstance(self.raw_data, pd.DataFrame) and "IWavePhi" in self.raw_data.index.names:
            self.__available_incident_wave_phi = np.unique(np.array(self.raw_data.index.get_level_values("IWavePhi")))
        return self.__available_incident_wave_phi

    @property
    def incident_wave_phi(self):
        """Active incident wave Phi."""
        if isinstance(self.raw_data, pd.DataFrame) and self.__incident_wave_phi is None:
            self.__incident_wave_phi = self.available_incident_wave_phi[0]
        return self.__incident_wave_phi

    @incident_wave_phi.setter
    def incident_wave_phi(self, val):
        """Active incident wave Phi."""
        if val in self.available_incident_wave_phi:
            self.__incident_wave_phi = val
        else:
            self.__logger.error("Value not available.")

    @property
    def frequency(self):
        """Active frequency."""
        return self.__frequency

    @frequency.setter
    def frequency(self, val):
        if isinstance(val, str):
            frequency, units = decompose_variable_value(val)
            unit_converter(frequency, "Freq", units, self.frequency_units)
            val = frequency
        if val in self.frequencies:
            self.__frequency = val
            # self.__freq_index = self.frequencies.index(val)
        else:
            self.__logger.error("Frequency not available.")

    @property
    def data_conversion_function(self):
        """RCS data conversion function.

        The available functions are:

        - `"dB10"`: Converts the data to decibels using base 10 logarithm.
        - `"dB20"`: Converts the data to decibels using base 20 logarithm.
        - `"abs"`: Computes the absolute value of the data.
        - `"real"`: Computes the real part of the data.
        - `"imag"`: Computes the imaginary part of the data.
        - `"norm"`: Normalizes the data to have values between 0 and 1.
        - `"ang"`: Computes the phase angle of the data in radians.
        - `"ang_deg"`: Computes the phase angle of the data in degrees.
        """
        return self.__data_conversion_function

    @data_conversion_function.setter
    def data_conversion_function(self, val):
        available_functions = ["dB10", "dB20", "abs", "real", "imag", "norm", "ang", "ang_deg", None]
        if val in available_functions:
            self.__data_conversion_function = val

    @property
    def window(self):
        """Window function.

        The available functions are: Options are ``"Flat"``, ``"Hamming``", and ``"Hann"``.
        """
        return self.__window

    @window.setter
    def window(self, val):
        available_functions = ["Flat", "Hamming", "Hann"]
        if val in available_functions:
            self.__window = val
        else:
            self.__logger.error("Invalid value for `window`. The value must be 'Flat', 'Hamming', or 'Hann'.")

    @property
    def window_size(self):
        """Window size."""
        return self.__window_size

    @window_size.setter
    def window_size(self, val):
        self.__window_size = val

    @property
    def aspect_range(self):
        """Aspect range for ISAR."""
        return self.__aspect_range

    @aspect_range.setter
    def aspect_range(self, val):
        self.__aspect_range = val

    @property
    def upsample_range(self):
        """Upsample range for ISAR."""
        return self.__upsample_range

    @upsample_range.setter
    def upsample_range(self, val):
        self.__upsample_range = val

    @property
    def upsample_azimuth(self):
        """Upsample azimuth for ISAR."""
        return self.__upsample_azimuth

    @upsample_azimuth.setter
    def upsample_azimuth(self, val):
        self.__upsample_azimuth = val

    @property
    def rcs(self):
        """RCS data for active frequency, theta and phi."""
        rcs_value = None
        if isinstance(self.raw_data, pd.DataFrame):
            data = self.rcs_active_theta_phi
            data = data[data["Freq"] == self.frequency]
            data = data.drop(columns=["Freq"])
            value = data.values
            rcs_value = value[0][0]
        return rcs_value

    @property
    def rcs_active_theta_phi(self):
        """RCS data for active theta and phi."""
        rcs_value = None
        if isinstance(self.rcs_active_theta, pd.DataFrame):
            data = self.rcs_active_theta
            data = data[data["IWavePhi"] == self.incident_wave_phi]
            rcs_value = data.drop(columns=["IWavePhi"])
        return rcs_value

    @property
    def rcs_active_frequency(self):
        """RCS data for active frequency."""
        value = None
        if isinstance(self.raw_data, pd.DataFrame):
            data = self.raw_data.xs(key=self.frequency, level="Freq")
            data_converted = conversion_function(data[self.name], self.data_conversion_function)
            df = data_converted.reset_index()
            df.columns = ["IWavePhi", "IWaveTheta", "Data"]
            value = df
        return value

    @property
    def rcs_active_theta(self):
        """RCS data for active incident wave theta."""
        value = None
        if isinstance(self.raw_data, pd.DataFrame):
            data = self.raw_data.xs(key=self.incident_wave_theta, level="IWaveTheta")
            df = data.reset_index()
            df.columns = ["Freq", "IWavePhi", "Data"]
            value = df
        return value

    @property
    def rcs_active_phi(self):
        """RCS data for active incident wave phi."""
        value = None
        if isinstance(self.raw_data, pd.DataFrame):
            data = self.raw_data.xs(key=self.incident_wave_phi, level="IWavePhi")
            df = data.reset_index()
            df.columns = ["Freq", "IWaveTheta", "Data"]
            value = df
        return value

    @property
    def range_profile(self):
        """Range profile."""
        value = None
        if isinstance(self.raw_data, pd.DataFrame):
            data = self.rcs_active_theta_phi["Data"]
            # Take needed properties
            size = self.window_size
            nfreq = len(self.frequencies)

            # Compute window
            win_range, _ = self.window_function(self.window, nfreq)
            windowed_data = data * win_range

            # Perform FFT
            sf_upsample = self.window_size / nfreq
            windowed_data = np.fft.fftshift(sf_upsample * np.fft.ifft(windowed_data.to_numpy(), n=size))

            data_converted = conversion_function(windowed_data, self.data_conversion_function)

            df = unit_converter((self.frequencies[1] - self.frequencies[0]), "Freq", self.frequency_units, "Hz")
            pd_t = 1.0 / df
            dt = pd_t / size
            range_norm = dt * np.linspace(start=-0.5 * size, stop=0.5 * size - 1, num=size) / 2 * SpeedOfLight

            index_names = ["Range", "Data"]
            df = pd.DataFrame(columns=index_names)
            df["Range"] = range_norm
            df["Data"] = data_converted
            value = df

        return value

    @property
    def waterfall(self):
        """Waterfall."""
        waterfall_df = None
        if isinstance(self.raw_data, pd.DataFrame):
            waterfall_data = []
            original_phi = self.incident_wave_phi
            for phi in self.available_incident_wave_phi:
                self.incident_wave_phi = phi
                range_profile = self.range_profile
                new_data = {
                    "Range": range_profile["Range"],
                    "Data": range_profile["Data"],
                    "IWavePhi": np.full(range_profile["Range"].size, phi),
                }
                waterfall_data.append(pd.DataFrame(new_data))
            self.incident_wave_phi = original_phi
            waterfall_df = pd.concat(waterfall_data, ignore_index=True)
        return waterfall_df

    @property
    def isar_2d(self):
        """ISAR 2D."""
        df = None
        if isinstance(self.raw_data, pd.DataFrame):
            phis = self.available_incident_wave_phi
            thetas = self.available_incident_wave_theta
            nfreq = len(self.frequencies)

            ndrng = self.upsample_range
            nxrng = self.upsample_azimuth
            if self.aspect_range == "Horizontal":
                nangles = len(phis)
                azel_samples = -phis.reshape(1, -1)
                data = self.rcs_active_theta["Data"]
            else:
                nangles = len(thetas)
                azel_samples = 90.0 - thetas.reshape(1, -1)
                data = self.rcs_active_phi["Data"]

            azel_samples = np.unwrap(np.radians(azel_samples))
            azel_ctr = np.mean(azel_samples)

            azel_span = np.max(azel_samples) - np.min(azel_samples)

            freqs = unit_converter(self.frequencies, "Freq", self.frequency_units, "Hz")
            freqs = np.unique(freqs)
            freqs = freqs.reshape(-1, 1)

            # True fx and fy locations for this f, az grid
            fxtrue = freqs * np.cos(azel_samples - azel_ctr)
            fxtrue = fxtrue.reshape(-1)
            fytrue = freqs * np.sin(azel_samples - azel_ctr)
            fytrue = fytrue.reshape(-1)

            # TODO: check that f_c is correct, because this is not the center frequency as we
            # define it in SBR
            fxmin = np.min(freqs)
            fxmax = np.max(freqs)
            f_c = np.mean(freqs)
            fymax = np.sin(azel_span / 2) * f_c

            fx = np.linspace(fxmin, fxmax, nfreq)  # desired downrange frequencies
            fy = np.linspace(-fymax, fymax, nangles)  # desired crossrange frequencies
            grid_x, grid_y = np.meshgrid(fx, fy)

            rdata = scipy.interpolate.griddata((fxtrue, fytrue), data, (grid_x, grid_y), "linear", fill_value=0.0)
            rdata = rdata.transpose()

            # Zero padding
            if ndrng < nfreq:
                # Warning('nx should be at least as large as the length of f -- increasing nx')
                self.__logger.warning("nx should be at least as large as the number of frequencies.")
                ndrng = nfreq
            if nxrng < nangles:
                # warning('ny should be at least as large as the length of az -- increasing ny');
                self.__logger.warning("ny should be at least as large as the number of azimuth angles.")
                nxrng = nangles

            #  Compute the image plane downrange and cross-range distance vectors (in
            #  meters)
            dfx = fx[1] - fx[0]  # difference in x-frequencies
            dfy = fy[1] - fy[0]  # difference in y-frequencies
            dx = SpeedOfLight / (2 * dfx) / ndrng
            dy = SpeedOfLight / (2 * dfy) / nxrng
            x = np.transpose(np.arange(start=0, step=dx, stop=ndrng * dx))  # ndrng x 1
            y = np.arange(start=0, step=dy, stop=nxrng * dy)  # 1 x Ny

            # We want the physical extents of the image to be centered at the global origin, because
            # that's how we draw the extents of the 2D ISAR domain.
            # The center of the first pixel in the second half of each domain is centered at zero
            # if the domain has odd length, but it is at dx/2 otherwise.
            if ndrng % 2 == 0:
                dArg = np.pi / ndrng  # 2pi/(dx/2)/(dx*ndrng)
                tmp = np.floor(-0.5 * nfreq) * dArg
                rngShiftBase = complex(np.cos(tmp), np.sin(tmp))
                rngShiftDelta = complex(np.cos(dArg), np.sin(dArg))
                for iF in range(0, nfreq):
                    rdata[iF, :] *= rngShiftBase
                    rngShiftBase *= rngShiftDelta

            if nxrng % 2 == 0:
                dArg = np.pi / nxrng
                tmp = np.floor(-0.5 * nangles) * dArg
                rngShiftBase = complex(np.cos(tmp), np.sin(tmp))
                rngShiftDelta = complex(np.cos(dArg), np.sin(dArg))
                for iA in range(0, nangles):
                    rdata[:, iA] *= rngShiftBase
                    rngShiftBase *= rngShiftDelta

            winx, winx_sum = self.window_function(self.window, nfreq)
            winy, winy_sum = self.window_function(self.window, nangles)

            winx = winx.reshape(-1, 1)
            winy = winy.reshape(1, -1)

            iq = np.zeros((ndrng, nxrng), dtype=np.complex128)
            xshift = (ndrng - nfreq) // 2
            yshift = (nxrng - nangles) // 2
            iq[xshift : xshift + nfreq, yshift : yshift + nangles] = np.multiply(rdata, winx * winy)

            # Normalize so that unit amplitude scatterers have about unit amplitude in
            # the image (normalized for the windows).  The "about" comes because of the
            # truncation of the polar shape into a rectangular shape.
            #
            iq = np.fft.fftshift(iq) * ndrng * nxrng / winx_sum / winy_sum
            isar_image = np.fft.fftshift(np.fft.ifft2(iq))
            # Nx x Ny
            isar_image = conversion_function(isar_image, self.data_conversion_function)

            isar_image = isar_image.transpose()
            isar_image = isar_image[::-1, :]  # this used to be flipped, but it matched range/cross range defs now

            # bring the center of the PHYSICAL image to 0, which means the first pixel on the
            # second half is not at 0 for even length domains
            range_values = x - 0.5 * (x[-1] - x[0])
            range_values_interp = np.linspace(range_values[0], range_values[-1], num=ndrng)
            cross_range_values = y - 0.5 * (y[-1] - y[0])
            cross_range_values_interp = np.linspace(cross_range_values[0], cross_range_values[-1], num=nxrng)

            rr, xr = np.meshgrid(range_values_interp, cross_range_values_interp)

            rr_flat = rr.ravel()
            xr_flat = xr.ravel()
            isar_image_flat = isar_image.ravel()

            index_names = ["Down-range", "Cross-range", "Data"]
            df = pd.DataFrame(columns=index_names)
            df["Down-range"] = rr_flat
            df["Cross-range"] = xr_flat
            df["Data"] = isar_image_flat

        return df

    @staticmethod
    def window_function(window="Flat", size=512):
        """Window function.

        Parameters
        ----------
        window : str, optional.
            Window function. The default is ``"Flat"``. Options are ``"Flat"``, ``"Hamming``", and ``"Hann"``.
        size : int, optional
            Window size. The default is ``512``.

        Returns
        -------
        tuple
            Data windowed and data sum.
        """
        if window == "Hann":
            win = np.hanning(size)
        elif window == "Hamming":
            win = np.hamming(size)
        else:
            win = np.ones(size)
        win_sum = np.sum(win)
        win *= size / win_sum
        return win, win_sum

    @pyaedt_function_handler()
    def __init_rcs(self):
        """Load monostatic radar cross-section data.

        Returns
        -------
        bool
            ``True`` when successful, ``False`` when failed.
        """
        try:
            self.__raw_data = pd.read_hdf(self.__monostatic_file, key="df", mode="r")
        except ImportError as e:  # pragma: no cover
            self.__logger.error(f"Failed to load monostatic RCS data: {e}")
            return False
        return True


class MonostaticRCSPlotter(PyAedtBase):
    """Provides monostatic radar cross-section (RCS) plot functionalities.

    Parameters
    ----------
    rcs_data : :class:`ansys.aedt.core.visualization.advanced.rcs_visualization`, optional
        Monostatic RCS data object.

    Examples
    --------
    >>> from ansys.aedt.core import Hfss
    >>> from ansys.aedt.core.visualization.advanced.rcs_visualization import MonostaticRCSData
    >>> from ansys.aedt.core.visualization.advanced.rcs_visualization import MonostaticRCSPlotter
    >>> app = Hfss(version="2025.2", design="Antenna")
    >>> data = app.get_rcs_data()
    >>> metadata_file = data.metadata_file
    >>> app.desktop_class.close_desktop()
    >>> rcs_data = MonostaticRCSData(input_file=metadata_file)
    >>> rcs_plotter = MonostaticRCSPlotter(rcs_data)
    """

    def __init__(self, rcs_data=None):
        # Private
        self.__rcs_data = rcs_data
        self.__logger = logger
        self.__model_units = "meter"

        # Scene properties
        self.show_geometry = True
        self.__all_scene_actors = {"model": {}, "annotations": {}, "results": {}}
        self.__x_max, self.__x_min, self.__y_max, self.__y_min, self.__z_max, self.__z_min = 1, -1, 1, -1, 1, -1
        self.__model_info = None

        # Get geometries
        if self.__rcs_data and "model_info" in self.rcs_data.metadata.keys():
            self.__model_info = self.rcs_data.metadata["model_info"]
            obj_meshes = self.__get_geometry()
            self.__all_scene_actors["model"] = obj_meshes

        # Get model extent
        self.__get_model_extent()

    @property
    def rcs_data(self):
        """RCS data object."""
        return self.__rcs_data

    @property
    def model_info(self):
        """Geometry information."""
        return self.__model_info

    @property
    def model_units(self):
        """Model units."""
        return self.__model_units

    @property
    def all_scene_actors(self):
        """All scene actors."""
        return self.__all_scene_actors

    @property
    def extents(self):
        """Geometry extents."""
        return [self.__x_min, self.__x_max, self.__y_min, self.__y_max, self.__z_min, self.__z_max]

    @property
    def center(self):
        """Geometry center."""
        x_mid = (self.__x_max + self.__x_min) / 2
        z_mid = (self.__z_max + self.__z_min) / 2
        y_mid = (self.__y_max + self.__y_min) / 2
        return np.array([x_mid, y_mid, z_mid])

    @property
    def radius(self):
        """Geometry radius."""
        return max(
            [abs(a) for a in (self.__x_min, self.__x_max, self.__y_min, self.__y_max, self.__z_min, self.__z_max)]
        )

    @pyaedt_function_handler()
    def plot_rcs(
        self,
        primary_sweep="IWavePhi",
        secondary_sweep="IWaveTheta",
        secondary_sweep_value=None,
        title="Monostatic RCS",
        output_file=None,
        show=True,
        is_polar=False,
        show_legend=True,
        size=(1920, 1440),
    ):
        """Create a 2D plot of the monostatic RCS.

        Parameters
        ----------
        primary_sweep : str, optional.
            X-axis variable. The default is ``"IWavePhi"``. Options are ``"Freq"``, ``"IWavePhi"`` and ``"IWaveTheta"``.
        secondary_sweep : str, optional.
            X-axis variable. The default is ``"IWavePhi"``. Options are ``"Freq"``, ``"IWavePhi"`` and ``"IWaveTheta"``.
        secondary_sweep_value : float, list, string, optional
            List of cuts on the secondary sweep to plot. The default is ``0``. Options are
            `"all"`, a single value float, or a list of float values.
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        is_polar : bool, optional
            Whether this plot is a polar plot. The default is ``True``.
        show_legend : bool, optional
            Whether to display the legend or not. The default is ``True``.
        size : tuple, optional
            Image size in pixel (width, height).

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            PyAEDT matplotlib figure object.
        """
        curves = []
        all_secondary_sweep_value = secondary_sweep_value
        if primary_sweep.casefold() == "freq" or primary_sweep.casefold() == "frequency":
            x_key = "Freq"
            x = self.rcs_data.frequencies
            if secondary_sweep == "IWaveTheta":
                if all_secondary_sweep_value is None:
                    all_secondary_sweep_value = self.rcs_data.incident_wave_theta
                data = self.rcs_data.rcs_active_phi
                data["Data"] = conversion_function(data["Data"], self.rcs_data.data_conversion_function)
                y_key = "IWaveTheta"

            else:
                if all_secondary_sweep_value is None:
                    all_secondary_sweep_value = self.rcs_data.incident_wave_phi
                data = self.rcs_data.rcs_active_theta
                data["Data"] = conversion_function(data["Data"], self.rcs_data.data_conversion_function)

                y_key = "IWavePhi"
        else:
            data = self.rcs_data.rcs_active_frequency
            if primary_sweep.casefold() == "iwavephi":
                x_key = "IWavePhi"
                y_key = "IWaveTheta"
                x = self.rcs_data.available_incident_wave_phi
                if isinstance(secondary_sweep_value, str) and secondary_sweep_value == "all":
                    all_secondary_sweep_value = self.rcs_data.available_incident_wave_theta
                elif secondary_sweep_value is None:
                    all_secondary_sweep_value = self.rcs_data.incident_wave_theta
            else:
                x_key = "IWaveTheta"
                y_key = "IWavePhi"
                x = self.rcs_data.available_incident_wave_theta
                if isinstance(secondary_sweep_value, str) and secondary_sweep_value == "all":
                    all_secondary_sweep_value = self.rcs_data.available_incident_wave_phi
                elif secondary_sweep_value is None:
                    all_secondary_sweep_value = self.rcs_data.incident_wave_phi

        if all_secondary_sweep_value is not None:
            if not isinstance(all_secondary_sweep_value, np.ndarray) and not isinstance(
                all_secondary_sweep_value, list
            ):
                all_secondary_sweep_value = [all_secondary_sweep_value]

            for el in all_secondary_sweep_value:
                data_sweep = data[data[y_key] == el]["Data"]

                y = data_sweep.values
                if not isinstance(y, np.ndarray):  # pragma: no cover
                    raise Exception("Format of quantity is wrong.")
                curves.append([x, y, "{}={}".format(y_key, el)])

        if curves is not None:
            new = ReportPlotter()
            new.show_legend = show_legend
            new.title = title
            new.size = size
            if is_polar:
                props = {"x_label": x_key, "y_label": "RCS \n"}
                for pdata in curves:
                    name = pdata[2] if len(pdata) > 2 else "Trace"
                    new.add_trace(pdata[:2], 0, props, name=name)
                _ = new.plot_polar(traces=None, snapshot_path=output_file, show=show)
            else:
                from ansys.aedt.core.generic.constants import CSS4_COLORS

                k = 0
                for data in curves:
                    props = {"x_label": x_key, "y_label": "RCS", "line_color": list(CSS4_COLORS.keys())[k]}
                    k += 1
                    if k == len(list(CSS4_COLORS.keys())):
                        k = 0
                    name = data[2] if len(data) > 2 else "Trace"
                    new.add_trace(data[:2], 0, props, name)
                _ = new.plot_2d(None, output_file, show)
            return new

    @pyaedt_function_handler()
    def plot_rcs_3d(self, title="Monostatic RCS 3D", output_file=None, show=True, size=(1920, 1440)):
        """Create a 3D plot of the monostatic RCS.

        Parameters
        ----------
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        size : tuple, optional
            Image size in pixel (width, height).

        Returns
        -------
        :class:`matplotlib.pyplot.Figure`
            Matplotlib figure object.
            If ``show=True``, a Matplotlib figure instance of the plot is returned.
            If ``show=False``, the plotted curve is returned.
        """
        data = self.rcs_data.rcs_active_frequency

        rcs = data["Data"]
        rcs_max = np.max(rcs)
        rcs_min = np.min(rcs)

        rcs_renorm = rcs + np.abs(rcs_min) if rcs_min else rcs
        rcs_renorm = rcs_renorm.to_numpy()
        # Negative values are not valid, this is cleaning numerical issues
        rcs_renorm[rcs_renorm < 0] = 0.0

        theta = np.deg2rad(data["IWaveTheta"])
        phi = np.deg2rad(data["IWavePhi"])
        unique_phi, unique_theta = np.unique(phi), np.unique(theta)
        phi_grid, theta_grid = np.meshgrid(unique_phi, unique_theta)

        r = np.full(phi_grid.shape, np.nan)

        idx_theta = np.digitize(theta, unique_theta) - 1
        idx_phi = np.digitize(phi, unique_phi) - 1
        r[idx_theta, idx_phi] = rcs_renorm

        x = r * np.sin(theta_grid) * np.cos(phi_grid)
        y = r * np.sin(theta_grid) * np.sin(phi_grid)
        z = r * np.cos(theta_grid)

        new = ReportPlotter()
        new.show_legend = True
        new.title = title
        new.size = size
        quantity = f"RCS {self.rcs_data.data_conversion_function}"
        props = {"x_label": "IWaveTheta", "y_label": "IWavePhi", "z_label": quantity}

        new.add_trace([x, y, z], 2, props, title)
        _ = new.plot_3d(trace=0, snapshot_path=output_file, show=show, color_map_limits=[rcs_min, rcs_max])

        return new

    @pyaedt_function_handler()
    def plot_range_profile(
        self,
        title="Range profile",
        output_file=None,
        show=True,
        show_legend=True,
        size=(1920, 1440),
    ):
        """Create a 2D plot of the range profile.

        Parameters
        ----------
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        show_legend : bool, optional
            Whether to display the legend or not. The default is ``True``.
        size : tuple, optional
            Image size in pixel (width, height).

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            PyAEDT matplotlib figure object.
        """
        data_range_profile = self.rcs_data.range_profile

        ranges = np.unique(data_range_profile["Range"])
        phi = self.rcs_data.incident_wave_phi
        theta = self.rcs_data.incident_wave_phi

        y = data_range_profile["Data"].to_numpy()

        legend = f"Phi={np.round(phi, 3)} Theta={np.round(theta, 3)}"
        curve = [ranges.tolist(), y.tolist(), legend]

        new = ReportPlotter()
        new.show_legend = show_legend
        new.title = title
        new.size = size
        props = {"x_label": "Range (m)", "y_label": f"Range Profile ({self.rcs_data.data_conversion_function})"}
        name = curve[2]
        new.add_trace(curve[:2], 0, props, name)
        _ = new.plot_2d(None, output_file, show)

        return new

    @pyaedt_function_handler()
    def plot_waterfall(
        self, title="Waterfall", output_file=None, show=True, is_polar=False, size=(1920, 1440), figure=None
    ):
        """Create a 2D contour plot of the waterfall.

        Parameters
        ----------
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        is_polar : bool, optional
            Whether to display in polar coordinates. The default is ``True``.
        size : tuple, optional
            Image size in pixel (width, height).
        figure : :class:`matplotlib.pyplot.Figure`, optional
            An existing Matplotlib `Figure` to which the plot is added.
            If not provided, a new `Figure` and `Axes` objects are created.
            Default is ``None``.

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            PyAEDT matplotlib figure object.
        """
        data_range_waterfall = self.rcs_data.waterfall

        ranges = np.unique(data_range_waterfall["Range"])
        phis = np.unique(data_range_waterfall["IWavePhi"])

        phis = np.deg2rad(phis.tolist()) if is_polar else phis
        n_range = len(ranges)
        n_phi = len(phis)
        values = data_range_waterfall["Data"].to_numpy()
        values = values.reshape((n_range, n_phi), order="F")

        ra, ph = np.meshgrid(ranges, phis)

        if is_polar:
            x = ph
            y = ra
            xlabel = " "
            ylabel = " "
        else:
            x = ra
            y = ph
            xlabel = "Range (m)"
            ylabel = "Phi (deg)"

        plot_data = [values.T, y, x]

        new = ReportPlotter()
        new.size = size
        new.show_legend = False
        new.title = title
        props = {
            "x_label": xlabel,
            "y_label": ylabel,
        }

        new.add_trace(plot_data, 0, props)
        _ = new.plot_contour(
            trace=0,
            polar=is_polar,
            snapshot_path=output_file,
            show=show,
            figure=figure,
            is_spherical=False,
            max_theta=ra.max(),
            min_theta=ra.min(),
        )
        return new

    @pyaedt_function_handler()
    def plot_isar_2d(self, title="ISAR", output_file=None, show=True, size=(1920, 1440), figure=None):
        """Create a 2D contour plot of the ISAR.

        Parameters
        ----------
        title : str, optional
            Plot title. The default is ``"RectangularPlot"``.
        output_file : str, optional
            Full path for the image file. The default is ``None``, in which case an image in not exported.
        show : bool, optional
            Whether to show the plot. The default is ``True``.
            If ``False``, the Matplotlib instance of the plot is shown.
        size : tuple, optional
            Image size in pixel (width, height).
        figure : :class:`matplotlib.pyplot.Figure`, optional
            An existing Matplotlib `Figure` to which the plot is added.
            If not provided, a new `Figure` and `Axes` objects are created.
            Default is ``None``.

        Returns
        -------
        :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
            PyAEDT matplotlib figure object.
        """
        data_isar = self.rcs_data.isar_2d

        ranges = np.unique(data_isar["Down-range"])
        phis = np.unique(data_isar["Cross-range"])

        n_range = len(ranges)
        n_phi = len(phis)
        values = data_isar["Data"].to_numpy()
        values = values.reshape((n_range, n_phi), order="F")

        ra, ph = np.meshgrid(ranges, phis)

        x = ph.T
        y = ra.T
        xlabel = "Range (m)"
        ylabel = "Cross Range (m)"

        plot_data = [values, x, y]

        new = ReportPlotter()
        new.size = size
        new.show_legend = False
        new.title = title
        props = {
            "x_label": xlabel,
            "y_label": ylabel,
        }

        new.add_trace(plot_data, 0, props)
        _ = new.plot_pcolor(trace=0, snapshot_path=output_file, show=show, figure=figure)
        return new

    @pyaedt_function_handler()
    @graphics_required
    def plot_scene(self, show=True):
        """Plot the 3D scene including models, annotations, and results.

        This method visualizes the 3D scene by rendering the mesh objects under the "model",
        "annotations", and "results" categories stored in `self.all_scene_actors`. The meshes
        are rendered using a default PyVista plotter.

        Parameters
        ----------
        show : bool, optional
            Whether to immediately display the plot. If ``True``, the plot will be displayed using ``plotter.show()``.
            If ``False``, the ``plotter`` object is returned for further customization before rendering.
            The default is ``True``.

        Returns
        -------
        pyvista.Plotter or None
            Returns the ``Plotter`` object if ``show`` is set to ``False``. If ``show`` is ``True``,
            the plot is displayed and no value is returned.
        """
        pv_backend = PyVistaBackend(allow_picking=True, plot_picked_names=True)
        plotter = Plotter(backend=pv_backend)

        if self.show_geometry:
            for geo in self.all_scene_actors["model"].values():
                self.__add_mesh(geo, plotter, "model")

        for annotations in self.all_scene_actors["annotations"]:
            for annotation in self.all_scene_actors["annotations"][annotations].values():
                if annotation.custom_object.show:
                    self.__add_mesh(annotation, plotter, "annotations")

        for all_scene_results in self.all_scene_actors["results"]:
            for result_actor in self.all_scene_actors["results"][all_scene_results].values():
                self.__add_mesh(result_actor, plotter, "results")

        plotter.backend.scene.show_grid(
            xtitle=f"X Axis ({self.model_units})",
            ytitle=f"Y Axis ({self.model_units})",
            ztitle=f"Z Axis ({self.model_units})",
        )
        if show:
            plotter.show()
        else:
            return plotter

    @pyaedt_function_handler()
    @graphics_required
    def add_rcs(
        self,
        color_bar="jet",
    ):
        """Add a 3D RCS representation to the current scene.

        This function normalizes and visualizes RCS data on a spherical coordinate grid
        (theta, phi), mapping it to 3D Cartesian coordinates (x, y, z). The RCS values are
        color-mapped and added as a mesh to the current scene actors.

        Parameters
        ----------
        color_bar : str, optional
            Color mapping to be applied to the RCS data. It can be a color (``"blue"``,
            ``"green"``, ...) or a colormap (``"jet"``, ``"viridis"``, ...). The default is ``"jet"``.
        """
        data = self.rcs_data.rcs_active_frequency

        new_data = self.stretch_data(data, scaling_factor=self.extents[5] - self.extents[4], offset=self.extents[4])

        rcs = new_data["Data"]

        rcs_min = np.min(rcs)

        rcs_renorm = rcs + np.abs(rcs_min) if rcs_min else rcs
        rcs_renorm = rcs_renorm.to_numpy()
        # Negative values are not valid, this is cleaning numerical issues
        rcs_renorm[rcs_renorm < 0] = 0.0

        theta = np.deg2rad(data["IWaveTheta"])
        phi = np.deg2rad(data["IWavePhi"])
        unique_phi, unique_theta = np.unique(phi), np.unique(theta)
        phi_grid, theta_grid = np.meshgrid(unique_phi, unique_theta)

        r = np.full(phi_grid.shape, np.nan)

        idx_theta = np.digitize(theta, unique_theta) - 1
        idx_phi = np.digitize(phi, unique_phi) - 1
        r[idx_theta, idx_phi] = rcs_renorm

        x = r * np.sin(theta_grid) * np.cos(phi_grid)
        y = r * np.sin(theta_grid) * np.sin(phi_grid)
        z = r * np.cos(theta_grid)

        actor = pv.StructuredGrid(x, y, z)
        actor.point_data["values"] = data["Data"]

        all_results_actors = list(self.all_scene_actors["results"].keys())

        if "rcs" not in all_results_actors:
            self.all_scene_actors["results"]["rcs"] = {}

        index = 0
        while f"rcs_{index}" in self.all_scene_actors["results"]["rcs"]:
            index += 1

        rcs_name = f"rcs_{index}"

        rcs_object = SceneMeshObject()
        rcs_object.name = rcs_name
        rcs_object.line_width = 1.0

        scalar_dict = dict(color="#000000", title="RCS")
        rcs_object.scalar_dict = scalar_dict

        if any(color_bar in x for x in ["blue", "green", "black", "red"]):
            rcs_object.color = color_bar
        else:
            rcs_object.cmap = color_bar

        rcs_object.mesh = actor

        rcs_mesh = MeshObjectPlot(rcs_object, rcs_object.get_mesh())

        self.all_scene_actors["results"]["rcs"][rcs_name] = rcs_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_range_profile_settings(
        self,
        size_range=10.0,
        range_resolution=0.1,
        tick_color="#000000",
        line_color="#ff0000",
        disc_color="ff0000",
        cone_color="#00ff00",
    ):
        """Add a 3D range profile setting representation to the current scene.

        This function visualizes a 3D range profile with a main line representing the range axis
        and tick marks indicating distance intervals. The profile includes visual elements like
        a disc at the far end and a cone at the starting point. These elements help to display
        a reference range profile in the 3D scene.

        Parameters
        ----------
        size_range : float, optional
            Total size of the range in ``meters``. It determines the length of the range
            profile. The default is ``10.0``.
        range_resolution : float, optional
            Resolution of the range in ``meters``, representing the distance between each tick mark along
            the range profile. The default is ``0.1``.
        tick_color : str, optional
            Color of the tick marks along the range profile. The default is black (``"#000000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        disc_color : str, optional
            Color of the disc. The default is red (``"#ff0000"``).
        cone_color : str, optional
            Color of the cone. The default is green (``"#00ff00"``).
        """
        size_range = unit_converter(
            size_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        range_resolution = unit_converter(
            range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        # Compute parameters
        range_max = size_range - range_resolution
        range_num = int(np.round(size_range / range_resolution))
        distance_range = np.linspace(0, range_max, range_num)
        distance_range -= distance_range[range_num // 2]
        range_first = -distance_range[0]
        range_last = -distance_range[-1]
        num_ticks = int(size_range / range_resolution)
        # Using 5% of total range length
        tick_length = size_range * 0.05

        if "range_profile" not in self.all_scene_actors["annotations"]:
            self.all_scene_actors["annotations"]["range_profile"] = {}

        # TODO: Do we want to support non-centered Range profile?
        center = np.array([0.0, 0.0, 0.0])

        # Main red line
        name = "main_line"
        main_line_az_mesh = self._create_line(
            pointa=(range_first + center[0], self.extents[2] * 10 + center[1], center[2]),
            pointb=(range_last + center[0], self.extents[2] * 10 + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["range_profile"][name] = main_line_az_mesh

        # Ticks
        tick_lines = pv.PolyData()
        for tick in range(num_ticks + 1):  # create line with tick marks
            if tick % 1 == 0:  # only do every nth tick
                tick_pos_start = (
                    range_first - range_resolution * tick + center[0],
                    self.extents[2] * 10 + center[1],
                    center[2],
                )
                tick_pos_end = (
                    range_first - range_resolution * tick + center[0],
                    self.extents[2] * 10 + tick_length + center[1],
                    center[2],
                )
                tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

        annotation_name = "ticks"
        tick_lines_object = SceneMeshObject()
        tick_lines_object.name = annotation_name
        tick_lines_object.color = tick_color
        tick_lines_object.line_width = 2
        tick_lines_object.mesh = tick_lines

        tick_lines_mesh = MeshObjectPlot(tick_lines_object, tick_lines_object.get_mesh())
        self.all_scene_actors["annotations"]["range_profile"][annotation_name] = tick_lines_mesh

        start_geo = pv.Disc(
            center=(range_last + center[0], self.extents[2] * 10 + center[1], center[2]),
            outer=tick_length,
            inner=0,
            normal=(-1, 0, 0),
            c_res=12,
        )

        annotation_name = "disc"
        start_geo_object = SceneMeshObject()
        start_geo_object.name = annotation_name
        start_geo_object.color = disc_color
        start_geo_object.line_width = 5
        start_geo_object.mesh = start_geo

        disc_geo_mesh = MeshObjectPlot(start_geo_object, start_geo_object.get_mesh())
        self.all_scene_actors["annotations"]["range_profile"][annotation_name] = disc_geo_mesh

        name = "cone"
        cone_center = (range_first + center[0], self.extents[2] * 10 + center[1], center[2])
        end_geo_mesh = self._create_cone(
            center=cone_center,
            direction=(-1, 0, 0),
            radius=tick_length,
            height=tick_length * 2,
            resolution=12,
            name=name,
            color=cone_color,
        )
        self.all_scene_actors["annotations"]["range_profile"][name] = end_geo_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_waterfall_settings(
        self, aspect_ang_phi=360.0, phi_num=10, tick_color="#000000", line_color="#ff0000", cone_color="#00ff00"
    ):
        """
        Add a 3D waterfall setting representation to the current scene.

        This function visualizes a 3D "waterfall" pattern that represents angular data across
        a circular arc in a spherical coordinate system. The arc covers an angular extent defined
        by the ``aspect_ang_phi`` parameter, with optional tick marks along the arc. A cone is added
        to indicate the endpoint of the angular sweep.

        Parameters
        ----------
        aspect_ang_phi : float, optional
            The angular extent of the arc in degrees. It defines the total angle (in degrees) over
            which the circular arc spans. The default is ``360.0`` degrees (full circle).
        phi_num : int, optional
            The number of tick marks to be placed along the arc. The default is ``10``.
        tick_color : str, optional
            Color of the tick marks. The default is black (``"#000000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        cone_color : str, optional
            Color of the cone. The default is green (``"#00ff00"``).
        """
        radius_max = self.radius

        # TODO: Do we want to support non-centered waterfall?
        center = np.array([0.0, 0.0, 0.0])

        angle = aspect_ang_phi - 1

        if "waterfall" not in self.all_scene_actors["annotations"]:
            self.all_scene_actors["annotations"]["waterfall"] = {}

        # Circular Arc
        x_start = center[0] + radius_max
        y_start = center[1]
        start_point = (x_start, y_start, center[2])
        end_point = [
            center[0] + radius_max * np.cos(np.deg2rad(angle)),
            center[1] + radius_max * np.sin(np.deg2rad(angle)),
            center[2],
        ]

        negative = False
        if angle >= 180:
            negative = True

        name = "arc"
        arc_mesh = self._create_arc(
            pointa=start_point,
            pointb=end_point,
            center=center,
            resolution=100,
            negative=negative,
            name="arc",
            color=line_color,
        )
        self.all_scene_actors["annotations"]["waterfall"][name] = arc_mesh

        # Ticks
        tick_spacing_deg = int(aspect_ang_phi) / phi_num

        if tick_spacing_deg >= 1:  # gets too cluttered if too small of spacing
            tick_lines = pv.PolyData()
            for tick in range(phi_num + 1):  # create line with tick marks
                if tick % 1 == 0:  # only do every nth tick
                    x_start = center[0] + radius_max * 0.95 * np.cos(np.deg2rad(tick * tick_spacing_deg))
                    y_start = center[1] + radius_max * 0.95 * np.sin(np.deg2rad(tick * tick_spacing_deg))
                    x_stop = center[0] + radius_max * 1.05 * np.cos(np.deg2rad(tick * tick_spacing_deg))
                    y_stop = center[1] + radius_max * 1.05 * np.sin(np.deg2rad(tick * tick_spacing_deg))
                    tick_pos_start = (x_start, y_start, center[2])
                    tick_pos_end = (x_stop, y_stop, center[2])
                    tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

            annotation_name = "ticks"
            tick_lines_object = SceneMeshObject()
            tick_lines_object.name = annotation_name
            tick_lines_object.color = tick_color
            tick_lines_object.line_width = 2
            tick_lines_object.mesh = tick_lines

            tick_lines_mesh = MeshObjectPlot(tick_lines_object, tick_lines_object.get_mesh())
            self.all_scene_actors["annotations"]["waterfall"][annotation_name] = tick_lines_mesh

        end_point = [
            center[0] + radius_max * np.cos(np.deg2rad(angle)),
            center[1] + radius_max * np.sin(np.deg2rad(angle)),
            center[2],
        ]
        end_point_plus_one = [
            center[0] + radius_max * np.cos(np.deg2rad(aspect_ang_phi)),
            center[1] + radius_max * np.sin(np.deg2rad(aspect_ang_phi)),
            center[2],
        ]
        direction = np.array(end_point_plus_one) - np.array(end_point)
        direction_mag = np.linalg.norm(direction)

        name = "cone"
        end_geo_mesh = self._create_cone(
            center=end_point,
            direction=direction,
            radius=direction_mag * 2,
            height=direction_mag * 4,
            resolution=12,
            name=name,
            color=cone_color,
        )
        self.all_scene_actors["annotations"]["waterfall"][name] = end_geo_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_isar_2d_settings(
        self,
        size_range=10.0,
        range_resolution=0.1,
        size_cross_range=10.0,
        cross_range_resolution=0.1,
        tick_color="#000000",
        line_color="#ff0000",
    ):
        """
        Add a preview frame of 2D ISAR (Inverse Synthetic Aperture Radar) visualization to the current 3D scene.

        This function creates a 2D range and cross-range profile in a 3D scene. It includes a main
        line to represent the range axis, tick marks to indicate distance intervals, and additional
        lines to mark the azimuth. The visualization aids in representing ISAR settings for both
        range and cross-range dimensions.

        Parameters
        ----------
        size_range : float, optional
            The total size of the range axis in meters. This sets the overall length of the
            range axis. The default is ``10.0 meters``.
        range_resolution : float, optional
            Resolution of the range axis in meters, specifying the spacing between each tick mark.
            The default is ``0.1 meters``.
        size_cross_range : float, optional
            The total size of the cross-range axis in meters. This sets the width of the cross-range
            axis. The default is ``10.0 meters``.
        cross_range_resolution : float, optional
            Resolution of the cross-range axis in meters, specifying the spacing between each tick mark
            along the azimuth axis. The default is ``0.1 meters``.
        tick_color : str, optional
            Color of the tick marks along both the range and cross-range axes. The default is
            black ("#000000").
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        size_range = unit_converter(
            size_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        range_resolution = unit_converter(
            range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        size_cross_range = unit_converter(
            size_cross_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        cross_range_resolution = unit_converter(
            cross_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )

        # Issue 47: self.center was incorrect. Do we want to support non-centered 2D ISAR?
        # The center can be moved only toward offset direction for example, it can be moved in
        # z direction if a plot in xy-plane
        # Do not use self.center, it is not correct
        center = np.array([0.0, 0.0, 0.0])

        # maximum number of ticks
        max_ticks = 64

        # Compute parameters
        range_max = size_range - range_resolution
        range_num = int(np.round(size_range / range_resolution))
        range_ticks = np.linspace(0, range_max, range_num)
        range_ticks -= (range_ticks[-1] - range_ticks[0]) / 2 + center[0]
        range_frame = np.array([-size_range / 2, size_range / 2])

        range_max_az = size_cross_range - cross_range_resolution
        range_num_az = int(np.round(size_cross_range / cross_range_resolution))
        range_ticks_az = np.linspace(0, range_max_az, range_num_az)
        range_ticks_az -= (range_ticks_az[-1] - range_ticks_az[0]) / 2 + center[1]
        range_frame_az = np.array([-size_cross_range / 2, size_cross_range / 2])

        num_ticks = range_num
        num_ticks_az = range_num_az

        # Using 5% of total range length
        tick_length = size_range * 0.05
        tick_length_az = size_cross_range * 0.05

        if "range_profile" not in self.all_scene_actors["annotations"]:
            self.all_scene_actors["annotations"]["isar_2d"] = {}

        # Main red line
        name = "main_line"
        main_line_mesh = self._create_line(
            pointa=(range_frame[0] + center[0], range_frame_az[0] + center[1], center[2]),
            pointb=(range_frame[-1] + center[0], range_frame_az[0] + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_2d"][name] = main_line_mesh

        name = "main_line_opposite"
        main_line_opposite_mesh = self._create_line(
            pointa=(range_frame[0] + center[0], range_frame_az[-1] + center[1], center[2]),
            pointb=(range_frame[-1] + center[0], range_frame_az[-1] + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_2d"][name] = main_line_opposite_mesh

        name = "main_line_az"
        main_line_az_mesh = self._create_line(
            pointa=(range_frame[0] + center[0], range_frame_az[0] + center[1], center[2]),
            pointb=(range_frame[0] + center[0], range_frame_az[-1] + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_2d"][name] = main_line_az_mesh

        pointa = (range_frame[1] + center[0], range_frame_az[0] + center[1], center[2])
        pointb = (range_frame[1] + center[0], range_frame_az[1] + center[1], center[2])
        name = "main_line_az_opposite"
        main_line_az_opposite_mesh = self._create_line(pointa=pointa, pointb=pointb, name=name, color=line_color)
        self.all_scene_actors["annotations"]["isar_2d"][name] = main_line_az_opposite_mesh

        tick_lines = pv.PolyData()
        for tick in range(0, num_ticks, num_ticks // max_ticks + 1):  # create line with tick marks
            tick_pos_start = (
                range_ticks[tick] + center[0],
                range_frame_az[0] + center[1],
                center[2],
            )
            tick_pos_end = (
                range_ticks[tick] + center[0],
                range_frame_az[0] - tick_length + center[1],
                center[2],
            )
            tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

        annotation_name = "ticks_range"
        tick_lines_range_object = SceneMeshObject()
        tick_lines_range_object.name = annotation_name
        tick_lines_range_object.color = tick_color
        tick_lines_range_object.line_width = 2
        tick_lines_range_object.mesh = tick_lines

        tick_lines_range_mesh = MeshObjectPlot(tick_lines_range_object, tick_lines_range_object.get_mesh())
        self.all_scene_actors["annotations"]["isar_2d"][annotation_name] = tick_lines_range_mesh

        # add azimuth line
        tick_lines = pv.PolyData()
        for tick in range(0, num_ticks_az, num_ticks // max_ticks + 1):  # create line with tick marks
            tick_pos_start = (range_frame[-1] + center[0], range_ticks_az[tick] + center[1], center[2])
            tick_pos_end = (
                range_frame[-1] + tick_length_az + center[0],
                range_ticks_az[tick] + center[1],
                center[2],
            )
            tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

        annotation_name = "ticks_az"
        tick_lines_az_object = SceneMeshObject()
        tick_lines_az_object.name = annotation_name
        tick_lines_az_object.color = tick_color
        tick_lines_az_object.line_width = 2
        tick_lines_az_object.mesh = tick_lines

        tick_lines_az_mesh = MeshObjectPlot(tick_lines_az_object, tick_lines_az_object.get_mesh())
        self.all_scene_actors["annotations"]["isar_2d"][annotation_name] = tick_lines_az_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_isar_3d_settings(
        self,
        size_range=10.0,
        range_resolution=0.1,
        size_cross_range=10.0,
        cross_range_resolution=0.1,
        size_elevation_range=10.0,
        elevation_range_resolution=0.1,
        tick_color="#000000",
        line_color="#ff0000",
    ):
        """
        Add a a preview frame of 3D ISAR (Inverse Synthetic Aperture Radar) visualization to the current 3D scene.

        Parameters
        ----------
        size_range : float, optional
            The total size of the range axis in meters. This sets the overall length of the
            range axis. The default is ``10.0 meters``.
        range_resolution : float, optional
            Resolution of the range axis in meters, specifying the spacing between each tick mark.
            The default is ``0.1 meters``.
        size_cross_range : float, optional
            The total size of the cross-range axis in meters. This sets the width of the cross-range
            axis. The default is ``10.0 meters``.
        cross_range_resolution : float, optional
            Resolution of the cross-range axis in meters, specifying the spacing between each tick mark
            along the azimuth axis. The default is ``0.1 meters``.
        size_elevation_range : float, optional
            The total size of the elevation-range axis in meters. This sets the width of the elevation-range
            axis. The default is ``10.0 meters``.
        elevation_range_resolution : float, optional
            Resolution of the elevation-range axis in meters, specifying the spacing between each tick mark
            along the elevation axis. The default is ``0.1 meters``.
        tick_color : str, optional
            Color of the tick marks along both the range and cross-range axes. The default is
            black (``"#000000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        size_range = unit_converter(
            size_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        range_resolution = unit_converter(
            range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        size_cross_range = unit_converter(
            size_cross_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        cross_range_resolution = unit_converter(
            cross_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        size_elevation_range = unit_converter(
            size_elevation_range, unit_system="Length", input_units="meter", output_units=self.model_units
        )
        elevation_range_resolution = unit_converter(
            elevation_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
        )

        # Compute parameters
        range_max = size_range - range_resolution
        range_num = int(np.round(size_range / range_resolution))
        distance_range = np.linspace(0, range_max, range_num)
        distance_range -= distance_range[range_num // 2]
        range_first = -distance_range[0]
        range_last = -distance_range[-1]

        range_max_az = size_cross_range - cross_range_resolution
        range_num_az = int(np.round(size_cross_range / cross_range_resolution))
        range_az = np.linspace(0, range_max_az, range_num_az)
        range_az -= range_az[range_num_az // 2]
        range_first_az = range_az[0]
        range_last_az = range_az[-1]

        range_max_el = size_elevation_range - elevation_range_resolution
        range_num_el = int(np.round(size_elevation_range / elevation_range_resolution))
        range_el = np.linspace(0, range_max_el, range_num_el)
        range_el -= range_el[range_num_az // 2]
        range_first_el = range_el[0]
        range_last_el = range_el[-1]

        num_ticks = range_num
        num_ticks_az = range_num_az
        num_ticks_el = range_num_el

        # Using 5% of total range length
        tick_length = size_range * 0.05
        tick_length_az = size_cross_range * 0.05
        tick_length_el = size_elevation_range * 0.05

        if "range_profile" not in self.all_scene_actors["annotations"]:
            self.all_scene_actors["annotations"]["isar_3d"] = {}

        # TODO: Do we want to support non-centered 3D ISAR?
        center = np.array([0.0, 0.0, 0.0])

        # Main red line
        name = "main_line"
        main_line_mesh = self._create_line(
            pointa=(range_first + center[0], range_first_az + center[1], center[2]),
            pointb=(range_last + center[0], range_first_az + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_mesh

        name = "main_line_opposite"
        main_line_opposite_mesh = self._create_line(
            pointa=(range_first + center[0], range_last_az + center[1], center[2]),
            pointb=(range_last + center[0], range_last_az + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_opposite_mesh

        name = "main_line_az"
        main_line_az_mesh = self._create_line(
            pointa=(range_first + center[0], range_first_az + center[1], center[2]),
            pointb=(range_first + center[0], range_last_az + center[1], center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_az_mesh

        pointa = (range_last + center[0], range_first_az + center[1], center[2])
        pointb = (range_last + center[0], range_last_az + center[1], center[2])
        name = "main_line_az_opposite"
        main_line_az_opposite_mesh = self._create_line(pointa=pointa, pointb=pointb, name=name, color=line_color)
        self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_az_opposite_mesh

        name = "main_line_el"
        main_line_el_mesh = self._create_line(
            pointa=(range_first + center[0], range_first_az + center[1], range_first_el + center[2]),
            pointb=(range_first + center[0], range_first_az + center[1], range_last_el + center[2]),
            name=name,
            color=line_color,
        )

        self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_el_mesh

        box_bounds = (
            range_first + center[0],
            range_last + center[0],
            range_first_az + center[1],
            range_last_az + center[1],
            range_first_el + center[2],
            range_last_el + center[2],
        )
        name = "box"
        box = pv.Box(box_bounds)
        box_object = SceneMeshObject()
        box_object.name = name
        box_object.color = line_color
        box_object.opacity = 0.05
        box_object.line_width = 5
        box_object.show_edges = True
        box_object.edge_color = line_color
        box_object.mesh = box

        box_mesh = MeshObjectPlot(box_object, box_object.get_mesh())

        self.all_scene_actors["annotations"]["isar_3d"][name] = box_mesh

        if num_ticks <= 256:  # don't display too many ticks
            tick_lines = pv.PolyData()
            for tick in range(1, num_ticks, 2):  # create line with tick marks
                tick_pos_start = (
                    distance_range[tick] + center[0],
                    range_first_az + center[1],
                    center[2],
                )
                tick_pos_end = (
                    distance_range[tick] + center[0],
                    range_first_az - tick_length + center[1],
                    center[2],
                )
                tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

            annotation_name = "ticks_range"
            tick_lines_range_object = SceneMeshObject()
            tick_lines_range_object.name = annotation_name
            tick_lines_range_object.color = tick_color
            tick_lines_range_object.line_width = 2
            tick_lines_range_object.mesh = tick_lines

            tick_lines_range_mesh = MeshObjectPlot(tick_lines_range_object, tick_lines_range_object.get_mesh())
            self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_range_mesh

        # add azimuth line
        if num_ticks_az <= 256:  # don't display too many ticks
            tick_lines = pv.PolyData()
            for tick in range(1, num_ticks_az - 1, 2):  # create line with tick marks
                tick_pos_start = (range_first + center[0], range_az[tick] + center[1], center[2])
                tick_pos_end = (
                    range_first + tick_length_az + center[0],
                    range_az[tick] + center[1],
                    center[2],
                )
                tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

            annotation_name = "ticks_az"
            tick_lines_az_object = SceneMeshObject()
            tick_lines_az_object.name = annotation_name
            tick_lines_az_object.color = tick_color
            tick_lines_az_object.line_width = 2
            tick_lines_az_object.mesh = tick_lines

            tick_lines_az_mesh = MeshObjectPlot(tick_lines_az_object, tick_lines_az_object.get_mesh())
            self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_az_mesh

        # add elevation line
        if num_ticks_el <= 256:  # don't display too many ticks
            tick_lines = pv.PolyData()
            for tick in range(1, num_ticks_el - 1, 2):  # create line with tick marks
                tick_pos_start = (
                    range_first + center[0],
                    range_first_az + center[1],
                    range_el[tick] + center[2],
                )
                tick_pos_end = (
                    range_first + tick_length_el + center[0],
                    range_first_az + center[1],
                    range_el[tick] + center[2],
                )
                tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)

            annotation_name = "ticks_el"
            tick_lines_el_object = SceneMeshObject()
            tick_lines_el_object.name = annotation_name
            tick_lines_el_object.color = tick_color
            tick_lines_el_object.line_width = 2
            tick_lines_el_object.mesh = tick_lines

            tick_lines_el_mesh = MeshObjectPlot(tick_lines_el_object, tick_lines_el_object.get_mesh())
            self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_el_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_range_profile(
        self,
        plot_type="Line",
        color_bar="jet",
    ):
        """Add the 3D range profile.

        This function visualizes a 3D range profile, which represents the RCS data
        as a function of range. The profile is rendered as a plot in the 3D scene using spherical coordinates
        (phi, theta) mapped into Cartesian coordinates (x, y). The range profile can be visualized in different
        plot types such as line plots or color-mapped surfaces.

        Parameters
        ----------
        plot_type : str, optional
            The type of plot to create for the range profile. It can be ``"Line"``, ``"Ribbon"``, ``"Rotated"``,
             ``"Extruded"``, ``"Plane V"``, `"Plane H"``, and `"Projection"``. The default is ``"Line"``.
        color_bar : str, optional
            Color mapping to be applied to the RCS data. It can be a color (``"blue"``,
            ``"green"``, ...) or a colormap (``"jet"``, ``"viridis"``, ...). The default is ``"jet"``.
        """
        data_range_profile = self.rcs_data.range_profile

        data_scaled = self.stretch_data(
            data_range_profile["Data"], scaling_factor=self.extents[5] - self.extents[4], offset=self.extents[4]
        )

        ranges = data_range_profile["Range"].to_numpy()
        phi = self.rcs_data.incident_wave_phi
        theta = self.rcs_data.incident_wave_theta

        cosphis = np.cos(np.radians(phi))
        sinphis = np.sin(np.radians(phi))
        sinthetas = np.sin(np.radians(theta))
        xpos = (-ranges) * cosphis * sinthetas
        ypos = (-ranges) * sinphis * sinthetas
        plot_data = data_scaled.to_numpy()

        cpos = data_range_profile["Data"].to_numpy()

        actor = self.__get_pyvista_range_profile_actor(
            xpos,
            ypos,
            plot_data,
            cpos,
            plot_type=plot_type,
            scene_actors=self.all_scene_actors["model"],
            data_conversion_function=self.rcs_data.data_conversion_function,
            extents=self.extents,
        )

        all_results_actors = list(self.all_scene_actors["results"].keys())

        if "range_profile" not in all_results_actors:
            self.all_scene_actors["results"]["range_profile"] = {}

        index = 0
        while f"range_profile_{index}" in self.all_scene_actors["results"]["range_profile"]:
            index += 1

        range_profile_name = f"range_profile_{index}"

        range_profile_object = SceneMeshObject()
        range_profile_object.name = range_profile_name
        range_profile_object.line_width = 1.0

        scalar_dict = dict(color="#000000", title="Range Profile")
        range_profile_object.scalar_dict = scalar_dict

        if any(color_bar in x for x in ["blue", "green", "black", "red"]):
            range_profile_object.color = color_bar
        else:
            range_profile_object.cmap = color_bar

        range_profile_object.mesh = actor

        range_profile_mesh = MeshObjectPlot(range_profile_object, range_profile_object.get_mesh())

        self.all_scene_actors["results"]["range_profile"][range_profile_name] = range_profile_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_waterfall(
        self,
        color_bar="jet",
    ):
        """Add the 3D waterfall."""
        data_waterfall = self.rcs_data.waterfall

        ranges = np.unique(data_waterfall["Range"])
        phis = np.unique(data_waterfall["IWavePhi"])
        values = data_waterfall["Data"].to_numpy()

        phis = np.deg2rad(phis)

        ra, ph = np.meshgrid(ranges, phis)

        x = -ra.T * np.cos(ph.T)
        y = -ra.T * np.sin(ph.T)
        z = np.zeros_like(x)

        actor = pv.StructuredGrid(x, y, z)
        actor.point_data["values"] = values

        all_results_actors = list(self.all_scene_actors["results"].keys())

        if "waterfall" not in all_results_actors:
            self.all_scene_actors["results"]["waterfall"] = {}

        index = 0
        while f"waterfall_{index}" in self.all_scene_actors["results"]["waterfall"]:
            index += 1

        waterfall_name = f"waterfall_{index}"

        waterfall_object = SceneMeshObject()
        waterfall_object.name = waterfall_name

        scalar_dict = dict(color="#000000", title="Waterfall")
        waterfall_object.scalar_dict = scalar_dict

        waterfall_object.cmap = color_bar

        waterfall_object.mesh = actor

        rcs_mesh = MeshObjectPlot(waterfall_object, waterfall_object.get_mesh())

        self.all_scene_actors["results"]["waterfall"][waterfall_name] = rcs_mesh

    @pyaedt_function_handler()
    @graphics_required
    def add_isar_2d(
        self,
        plot_type="plane",
        color_bar="jet",
    ):
        """Add the ISAR 2D.

        Parameters
        ----------
        plot_type : str, optional
            The type of plot to create for the range profile. It can be ``"plane"``, ``"relief"``, and `"projection"``.
            The default is ``"plane"``.
        color_bar : str, optional
            Color mapping to be applied to the RCS data. It can be a color (``"blue"``,
            ``"green"``, ...) or a colormap (``"jet"``, ``"viridis"``, ...). The default is ``"jet"``.
        """
        data_isar_2d = self.rcs_data.isar_2d

        down_range = data_isar_2d["Down-range"].unique()
        cross_range = data_isar_2d["Cross-range"].unique()

        values_2d = data_isar_2d.pivot(index="Cross-range", columns="Down-range", values="Data").to_numpy()

        # meshgrid must have one more pixel. In the other words, number of meshgrid points = number of values + 1
        # mesh idx  1   2   3   4   5
        #           | * | * | * | * |
        # value idx   1   2   3   4
        dx = down_range[1] - down_range[0]
        down_range_grid = np.linspace(down_range[0] - dx / 2, down_range[-1] + dx / 2, num=len(down_range) + 1)
        dy = cross_range[1] - cross_range[0]
        cross_range_grid = np.linspace(cross_range[0] - dy / 2, cross_range[-1] + dy / 2, num=len(cross_range) + 1)

        # TODO: revisit this! this only works for 2D ISAR on the theta = 90 plane
        x, y = np.meshgrid(down_range_grid[::-1], cross_range_grid[::-1])
        z = np.zeros_like(x)

        if plot_type.casefold() == "relief":
            m = 2.0
            b = -1.0
            # z = (values_2d - values_2d.min()) / (values_2d.max() - values_2d.min()) * m + b

            f_z = RegularGridInterpolator(
                (cross_range, down_range), values_2d, fill_value=None, method="linear", bounds_error=False
            )
            z = f_z((y, x))
            z = (z - z.min()) / (z.max() - z.min()) * m + b

        if plot_type.casefold() in ["relief", "plane"]:
            actor = pv.StructuredGrid()
            actor.points = np.c_[x.ravel(), y.ravel(), z.ravel()]

            actor.dimensions = (len(down_range_grid), len(cross_range_grid), 1)

            actor["values"] = values_2d.ravel()

        else:
            scene_actors = self.all_scene_actors["model"]
            if scene_actors is None:
                return None
            actor = pv.PolyData()
            for model_actor in scene_actors.values():
                mesh = model_actor.custom_object.get_mesh()
                xypoints = mesh.points
                cpos = values_2d.flatten()
                xpos_ypos = np.column_stack((x.flatten(), y.flatten(), cpos))
                all_indices = self.__find_nearest_neighbors(xpos_ypos, xypoints)

                mag_for_color = np.ndarray.flatten(cpos[all_indices])
                if mesh.__class__.__name__ != "PolyData":
                    mesh_triangulated = mesh.triangulate()
                    model_actor.custom_object.mesh = pv.PolyData(mesh_triangulated.points, mesh_triangulated.cells)
                else:
                    model_actor.custom_object.mesh.clear_data()
                model_actor.custom_object.mesh[self.rcs_data.data_conversion_function] = mag_for_color
                actor += model_actor.custom_object.mesh

        all_results_actors = list(self.all_scene_actors["results"].keys())

        if "isar_2d" not in all_results_actors:
            self.all_scene_actors["results"]["isar_2d"] = {}

        index = 0
        while f"isar_2d_{index}" in self.all_scene_actors["results"]["isar_2d"]:
            index += 1

        isar_name = f"isar_2d_{index}"

        isar_object = SceneMeshObject()
        isar_object.name = isar_name

        scalar_dict = dict(color="#000000", title="ISAR 2D")
        isar_object.scalar_dict = scalar_dict

        isar_object.cmap = color_bar

        isar_object.mesh = actor

        rcs_mesh = MeshObjectPlot(isar_object, isar_object.get_mesh())

        self.all_scene_actors["results"]["isar_2d"][isar_name] = rcs_mesh

    @pyaedt_function_handler()
    def add_incident_rcs_settings(
        self,
        theta_span: float,
        num_theta: int,
        phi_span: float,
        num_phi: int,
        arrow_color: str = "#ff0000",
        line_color: str = "#ff0000",
    ):
        """Add incident wave arrow setting for RCS scene.

        This function visualizes the incident wave arrows for RCS settings.

        Parameters
        ----------
        theta_span : float
            Incident theta angle in degrees.
        num_theta : int
            Number of theta points.
        phi_span : float
            Incident phi angle in degrees.
        num_phi : int
            Number of phi points.
        arrow_color : str, optional
            Color of the arrow. The default is red (``"#ff0000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        self._add_incident_settings(
            scene_type="rcs",
            theta_span=theta_span,
            num_theta=num_theta,
            phi_span=phi_span,
            num_phi=num_phi,
            arrow_color=arrow_color,
            line_color=line_color,
        )

    @pyaedt_function_handler()
    def add_incident_range_profile_settings(self, arrow_color: str = "#ff0000"):
        """Add incident wave arrow setting for range profile scene.

        This function visualizes the incident wave arrows for RCS settings.

        Parameters
        ----------
        arrow_color : str, optional
            Color of the arrow. The default is red (``"#ff0000"``).
        """
        self._add_incident_settings(scene_type="range_profile", arrow_color=arrow_color)

    @pyaedt_function_handler()
    def add_incident_waterfall_settings(
        self, phi_span: float, num_phi: int, arrow_color: str = "#ff0000", line_color: str = "#ff0000"
    ):
        """Add incident wave arrow setting for waterfall scene.

        This function visualizes the incident wave arrows for waterfall settings.

        Parameters
        ----------
        phi_span : float
            Incident phi angle in degrees.
        num_phi : int
            Number of phi points.
        arrow_color : str, optional
            Color of the arrow. The default is red (``"#ff0000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        self._add_incident_settings(
            scene_type="waterfall", phi_span=phi_span, num_phi=num_phi, arrow_color=arrow_color, line_color=line_color
        )

    @pyaedt_function_handler()
    def add_incident_isar_2d_settings(
        self, phi_span: float, num_phi: int, arrow_color: str = "#ff0000", line_color: str = "#ff0000"
    ):
        """Add incident wave arrow setting for ISAR 2D scene.

        This function visualizes the incident wave arrows for ISAR 2D settings.

        Parameters
        ----------
        phi_span : float
            Incident phi angle in degrees.
        num_phi : int
            Number of phi points.
        arrow_color : str, optional
            Color of the arrow. The default is red (``"#ff0000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        self._add_incident_settings(
            scene_type="isar_2d", phi_span=phi_span, num_phi=num_phi, arrow_color=arrow_color, line_color=line_color
        )

    @pyaedt_function_handler()
    def add_incident_isar_3d_settings(
        self,
        theta_span: float,
        num_theta: int,
        phi_span: float,
        num_phi: int,
        arrow_color: str = "#ff0000",
        line_color: str = "#ff0000",
    ):
        """Add incident wave arrow setting for ISAR 3D scene.

        This function visualizes the incident wave arrows for ISAR 3D settings.

        Parameters
        ----------
        theta_span : float
            Incident theta angle in degrees.
        num_theta : int
            Number of theta points.
        phi_span : float
            Incident phi angle in degrees.
        num_phi : int
            Number of phi points.
        arrow_color : str, optional
            Color of the arrow. The default is red (``"#ff0000"``).
        line_color : str, optional
            Color of the line. The default is red (``"#ff0000"``).
        """
        self._add_incident_settings(
            scene_type="isar_3d",
            theta_span=theta_span,
            num_theta=num_theta,
            phi_span=phi_span,
            num_phi=num_phi,
            arrow_color=arrow_color,
            line_color=line_color,
        )

    @pyaedt_function_handler()
    def clear_scene(self, first_level=None, second_level=None, name=None):
        if not first_level:
            self.all_scene_actors["annotations"] = {}
            self.all_scene_actors["results"] = {}
        elif first_level == "model":
            self.__logger.warning("Model can not be cleared. Set 'show_geometry' to False.")
            return False
        elif first_level in ["annotations", "results"]:
            if not second_level:
                self.all_scene_actors[first_level] = {}
            elif second_level in self.all_scene_actors[first_level].keys():  # pragma: no cover
                if not name:
                    self.all_scene_actor[first_level][second_level] = {}
                elif name in self.all_scene_actors[first_level][second_level].keys():
                    del self.all_scene_actors[first_level][second_level][name]
        return True

    @pyaedt_function_handler()
    def _add_incident_settings(
        self,
        scene_type="RCS",
        theta_span=0.0,
        num_theta=101,
        phi_span=0.0,
        num_phi=101,
        arrow_color="#ff0000",
        line_color="#ff0000",
    ):
        # Compute parameters
        radius_max = max([abs(self.extents[0]), abs(self.extents[1]), abs(self.extents[2]), abs(self.extents[3])])
        arrow_length = radius_max * 0.25

        if "incident_wave" not in self.all_scene_actors["annotations"]:
            self.all_scene_actors["annotations"]["incident_wave"] = {}

        # Plot arrows
        # if we end up wanting to plot points, we need to keep the whole span.
        # Arrows are only plotted at the edges of the domain
        theta = np.linspace(0, theta_span, num_theta)
        theta -= theta[len(theta) // 2]
        theta += 90
        theta = np.deg2rad([theta[0], theta[-1]])
        phi = np.linspace(0, phi_span, num_phi)
        phi -= phi[len(phi) // 2]
        phi = np.deg2rad([phi[0], phi[-1]])
        corners = ((theta[0], phi[0]), (theta[0], phi[1]), (theta[1], phi[1]), (theta[1], phi[0]))
        for i, (t, p) in enumerate(corners):
            arrow_direction = -np.array([np.cos(p) * np.sin(t), np.sin(p) * np.sin(t), np.cos(t)])
            arrow_start = -arrow_direction * (radius_max + arrow_length)
            name = "arrow" + str(i)
            arrow_mesh = self._create_arrow(
                start=arrow_start, direction=arrow_direction, scale=arrow_length, name=name, color=arrow_color
            )
            self.all_scene_actors["annotations"]["incident_wave"][name] = arrow_mesh

            corner1 = arrow_start
            n_t, n_p = corners[(i + 1) % 4]
            n_arrow_direction = -np.array([np.cos(n_p) * np.sin(n_t), np.sin(n_p) * np.sin(n_t), np.cos(n_t)])
            corner2 = -n_arrow_direction * (radius_max + arrow_length)
            name = "arc" + str(i)
            arc_mesh = self._create_arc(
                pointa=corner1,
                pointb=corner2,
                center=(0, 0, 0),
                resolution=100,
                negative=False,
                name=name,
                color=line_color,
            )
            self.all_scene_actors["annotations"]["incident_wave"][name] = arc_mesh
        return True

    @pyaedt_function_handler()
    @graphics_required
    def _create_arrow(self, start, direction, scale, name, color):
        arrow = pv.Arrow(start=start, direction=direction, scale=scale)
        arrow_object = SceneMeshObject()
        arrow_object.name = name
        arrow_object.color = color
        arrow_object.line_width = 5
        arrow_object.specular = 0.5
        arrow_object.lighting = True
        arrow_object.smooth_shading = False
        arrow_object.mesh = arrow

        return MeshObjectPlot(arrow_object, arrow_object.get_mesh())

    @pyaedt_function_handler()
    @graphics_required
    def _create_arc(self, pointa, pointb, center, resolution, negative, name, color):
        arc = pv.CircularArc(pointa=pointa, pointb=pointb, center=center, resolution=resolution, negative=negative)
        arc_object = SceneMeshObject()
        arc_object.name = name
        arc_object.color = color
        arc_object.line_width = 5
        arc_object.specular = 0.5
        arc_object.lighting = True
        arc_object.smooth_shading = False
        arc_object.mesh = arc

        return MeshObjectPlot(arc_object, arc_object.get_mesh())

    @pyaedt_function_handler()
    @graphics_required
    def _create_cone(self, center, direction, radius, height, resolution, name, color):
        cone = pv.Cone(center=center, direction=direction, radius=radius, height=height, resolution=resolution)
        cone_object = SceneMeshObject()
        cone_object.name = name
        cone_object.color = color
        cone_object.line_width = 5
        cone_object.specular = 0.5
        cone_object.lighting = True
        cone_object.smooth_shading = False
        cone_object.mesh = cone

        return MeshObjectPlot(cone_object, cone_object.get_mesh())

    @pyaedt_function_handler()
    @graphics_required
    def _create_line(self, pointa, pointb, name, color):
        line = pv.Line(pointa=pointa, pointb=pointb)
        line_object = SceneMeshObject()
        line_object.name = name
        line_object.color = color
        line_object.line_width = 5
        line_object.mesh = line

        return MeshObjectPlot(line_object, line_object.get_mesh())

    @pyaedt_function_handler()
    @graphics_required
    def __get_pyvista_range_profile_actor(
        self,
        xpos,
        ypos,
        plot_data,
        cpos,
        plot_type="Line",
        data_conversion_function="",
        scene_actors=None,
        extents=None,
    ):
        """Get PyVista actor for range profile 3D scene."""
        if extents is None:
            extents = [0, 10, 0, 10, 0, 10]

        plot_type_lower = plot_type.casefold()
        actor = None

        if (
            plot_type_lower == "line"
            or plot_type_lower == "ribbon"
            or plot_type_lower == "rotated"
            or plot_type_lower == "extruded"
        ):
            xyz_pos = np.stack((xpos, ypos, plot_data)).T
            actor = pv.lines_from_points(xyz_pos)
            actor[data_conversion_function] = cpos
            if plot_type_lower == "ribbon":
                norm_vect = [0, 0, 1]
                # min_width_for_renorm, max_width_for_renorm = get_min_max_width_window(plotter)
                x_max, x_min = max(extents[0], extents[1]), min(extents[0], extents[1])
                y_max, y_min = max(extents[2], extents[3]), min(extents[2], extents[3])
                radius_max = max([abs(x_max), abs(x_min), abs(y_max), abs(y_min)])
                min_width_for_renorm = -radius_max
                max_width_for_renorm = radius_max
                geo_width = max_width_for_renorm - min_width_for_renorm
                actor = actor.ribbon(width=geo_width / 2, normal=norm_vect)
            elif plot_type_lower == "rotated":
                v = xyz_pos[-1] - xyz_pos[0]
                v_hat = v / np.linalg.norm(v)
                actor.extrude_rotate(rotation_axis=v_hat, capping=True, inplace=True)
            elif plot_type_lower == "extruded":
                plane = pv.Plane(
                    center=(actor.center[0], actor.center[1], actor.bounds[4]),
                    direction=(0, 0, -1),
                    i_size=actor.bounds[1] - actor.bounds[0],
                    j_size=actor.bounds[3] - actor.bounds[2],
                )
                actor.extrude_trim((0, 0, -1.0), plane, inplace=True)
        elif plot_type_lower == "plane v" or plot_type_lower == "plane h":
            zpos = np.zeros(shape=plot_data.shape)
            xyz_pos = np.stack((xpos, ypos, zpos)).T
            actor = pv.lines_from_points(xyz_pos)
            actor[data_conversion_function] = cpos
            if plot_type_lower == "plane v":
                # min_height_for_renorm, max_height_for_renorm = get_min_max_height_window(plotter)
                geo_width = extents[5] - extents[4]
                norm_vect = [0, 1, 0]
            else:
                # min_width_for_renorm, max_width_for_renorm = get_min_max_width_window(plotter)
                x_max, x_min = max(extents[0], extents[1]), min(extents[0], extents[1])
                y_max, y_min = max(extents[2], extents[3]), min(extents[2], extents[3])
                radius_max = max([abs(x_max), abs(x_min), abs(y_max), abs(y_min)])
                min_width_for_renorm = -radius_max
                max_width_for_renorm = radius_max
                geo_width = max_width_for_renorm - min_width_for_renorm
                norm_vect = [0, 0, 1]
            actor = actor.ribbon(width=geo_width / 2, normal=norm_vect)
        elif plot_type_lower == "projection":
            if scene_actors is None:
                return None
            actor = pv.PolyData()
            for model_actor in scene_actors.values():
                mesh = model_actor.custom_object.get_mesh()
                xypoints = mesh.points
                xpos_ypos = np.column_stack((xpos, ypos, plot_data))
                all_indices = self.__find_nearest_neighbors(xpos_ypos, xypoints)
                mag_for_color = np.ndarray.flatten(cpos[all_indices])
                if not mesh.__class__.__name__ == "PolyData":
                    mesh_triangulated = mesh.triangulate()
                    model_actor.custom_object.mesh = pv.PolyData(mesh_triangulated.points, mesh_triangulated.cells)
                else:
                    model_actor.custom_object.mesh.clear_data()
                model_actor.custom_object.mesh[data_conversion_function] = mag_for_color
                actor += model_actor.custom_object.mesh
        return actor

    @staticmethod
    def stretch_data(data, scaling_factor, offset):
        """
        Stretches and scales the input data to a specified range.

        This method normalizes the input data between its minimum and maximum values and then applies
        a linear transformation using the formula: ``scaled_data = (data - min) / (max - min) * m + b``.
        The parameters ``m`` and ``b`` control the scaling and shifting of the normalized data.

        Parameters
        ----------
        data : numpy.ndarray or pandas.Series
            The input data array or series to be stretched.
        scaling_factor : float
            The scaling factor applied to the normalized data.
        offset : float
            The offset added to the scaled data after normalization.

        Returns
        -------
        numpy.ndarray or pandas.Series
            Transformed data

        Example:
        -------
        >>> data = np.array([1, 2, 3, 4, 5])
        >>> stretched_data = stretch_data(data, 2, 1)
        >>> print(stretched_data)
        [1.  1.5 2.  2.5 3. ]
        """
        return (data - data.min()) / (data.max() - data.min()) * scaling_factor + offset

    @staticmethod
    def __find_nearest_neighbors(xpos_ypos, xypoints):
        # Calculate squared Euclidean distance between each point in xypoints and xpos_ypos
        distances = ((xpos_ypos[:, np.newaxis] - xypoints) ** 2).sum(axis=2)

        # Find the index of the nearest neighbor for each query point in xypoints
        all_indices = np.argmin(distances, axis=0)

        return all_indices

    @staticmethod
    def __add_mesh(mesh_object, plotter, mesh_type="results"):
        """Add a mesh to the plotter with additional options."""
        options = {}
        if getattr(mesh_object, "custom_object", None):
            if mesh_type == "model":
                options = mesh_object.custom_object.get_model_options()
            elif mesh_type == "annotations":
                options = mesh_object.custom_object.get_annotation_options()
            else:
                options = mesh_object.custom_object.get_result_options()

        plotter.plot(mesh_object.mesh, **options)

    @pyaedt_function_handler()
    def __get_model_extent(self):
        """
        Calculate the 3D extent of the model by evaluating the bounding box dimensions
        of each mesh object in the scene.

        This method retrieves the maximum and minimum coordinates in the x, y, and z
        directions for all mesh objects stored under the "model" key in `self.all_scene_actors`.
        The bounding box of each mesh is assessed, and the overall bounds for the entire
        model are determined by taking the min/max values from these individual bounding boxes.
        """
        x_max, x_min, y_max, y_min, z_max, z_min = [], [], [], [], [], []

        if len(self.all_scene_actors["model"]) == 0:
            x_max = [1]
            x_min = [-1]
            y_max = [1]
            y_min = [-1]
            z_max = [1]
            z_min = [-1]
        for each in self.all_scene_actors["model"].values():
            x_max.append(each.mesh.bounds[1])
            x_min.append(each.mesh.bounds[0])
            y_max.append(each.mesh.bounds[3])
            y_min.append(each.mesh.bounds[2])
            z_max.append(each.mesh.bounds[5])
            z_min.append(each.mesh.bounds[4])
        self.__x_max, self.__x_min = max(x_max), min(x_min)
        self.__y_max, self.__y_min = max(y_max), min(y_min)
        self.__z_max, self.__z_min = max(z_max), min(z_min)

    @pyaedt_function_handler()
    @graphics_required
    def __get_geometry(self):
        """Get 3D meshes."""
        model_info = self.model_info

        obj_meshes = {}
        first_value = next(iter(model_info.values()))
        self.__model_units = first_value[3]
        for object_in in model_info.values():
            relative_cad_path, color, opacity, units = object_in
            relative_path = Path(relative_cad_path)
            name = relative_path.stem
            relative_path = Path("geometry") / relative_path
            cad_path = Path(self.rcs_data.output_dir) / relative_path
            try:
                conv = AEDT_UNITS["Length"][units]
            except Exception:
                conv = 1
            if cad_path.exists():
                mesh = pv.read(str(cad_path))
                mesh.scale(conv)
            else:
                self.__logger.warning(f"{cad_path} does not exist.")
                return False

            color_cad = [i / 255 for i in color]
            model_object = SceneMeshObject()
            model_object.color = color_cad
            model_object.opacity = opacity
            model_object.name = name
            model_object.mesh = mesh

            mesh_object = MeshObjectPlot(model_object, model_object.get_mesh())
            obj_meshes[model_object.name] = mesh_object

        return obj_meshes


class SceneMeshObject(PyAedtBase):
    """
    A class representing a custom 3D mesh object with visualization properties.

    This class defines a 3D mesh object with customizable properties.
    It provides methods to retrieve the mesh, its associated rendering options, and annotation properties for
    visualization in PyVista.

    """

    @graphics_required
    def __init__(self):
        # Public
        self.name = "CustomObject"
        self.opacity = 1.0
        self.color = None
        self.color_map = "jet"
        self.line_width = 1.0
        self.specular = 0.0
        self.lighting = False
        self.smooth_shading = True
        self.edge_color = None
        self.show_edges = True
        self.scalar_dict = dict(color="#000000", title="Dummy")
        self.show = True

        # Private
        self.__mesh = pv.Cube()
        self.__original_points = self.mesh.points.copy()
        self.__z_offset = 0.0
        self.__scale_factor = 1.0

    @property
    def mesh(self):
        """Get the mesh object."""
        return self.__mesh

    @mesh.setter
    def mesh(self, val):
        self.__mesh = val
        self.__original_points = val.points.copy()

    @property
    def z_offset(self):
        """Offset in the Z direction."""
        return self.__z_offset

    @z_offset.setter
    def z_offset(self, val):
        translation_distance = val

        # Calculate the new points by applying the translation to the original points
        new_points = self.__original_points.copy()
        new_points[:, 2] += translation_distance  # Apply Z translation

        # Update the mesh with the new points
        self.__mesh.points = new_points
        self.__z_offset = val

    @property
    def scale_factor(self):
        """Get the current scale factor."""
        return self.__scale_factor

    @scale_factor.setter
    def scale_factor(self, val):
        """Set a new scale factor and update the mesh accordingly."""
        scale_factor = val

        # Calculate the center of the mesh for scaling
        center = self.__mesh.points.mean(axis=0)  # Center of the original mesh

        # Calculate the new points by scaling relative to the original points and the mesh center
        new_points = self.__mesh.points.copy()
        new_points = center + (new_points - center) * scale_factor  # Apply scaling from the center

        # Update the mesh with the new points
        self.mesh.points = new_points
        self.__scale_factor = val  # Update the scale factor

    def reset_scene(self):
        """Reset the mesh to its original position and size."""
        self.mesh.points = self.__original_points.copy()  # Restore the original points
        self.__z_offset = 0.0  # Reset the Z-offset
        self.__scale_factor = 1.0  # Reset the scale factor

    def get_mesh(self):
        """Retrieve the mesh object.

        Returns
        -------
        pyvista.PolyData or pyvista.UnstructuredGrid
            The mesh object representing the 3D geometry.
        """
        return self.mesh

    def name(self):
        """Name."""
        return self.name

    def line_width(self):
        """Line width."""
        return self.name

    def opacity(self):
        """Opacity."""
        return self.opacity

    def color(self):
        """Color."""
        return self.color

    def scalar_dict(self):
        """Scalar bar dict."""
        return self.scalar_dict

    def get_model_options(self):
        """Retrieve the visualization options for the mesh.

        Returns
        -------
        dict
            A dictionary with the color and opacity settings for rendering the model.
        """
        return {"color": self.color, "opacity": self.opacity}

    def get_annotation_options(self):
        """Retrieve the annotation options for the mesh.

        Returns
        -------
        dict
            A dictionary with the color and line width settings for annotating the model.
        """
        return {
            "color": self.color,
            "line_width": self.line_width,
            "specular": self.specular,
            "lighting": self.lighting,
            "smooth_shading": self.smooth_shading,
            "opacity": self.opacity,
            "show_edges": self.show_edges,
            "edge_color": self.edge_color,
        }

    def get_result_options(self):
        """Retrieve the result options for the mesh.

        Returns
        -------
        dict
            A dictionary with the settings for results the model.
        """
        if self.color:
            return {"color": self.color, "line_width": self.line_width, "scalar_bar_args": self.scalar_dict}
        return {"cmap": self.color_map, "line_width": self.line_width, "scalar_bar_args": self.scalar_dict}
